Skip to content

Unify map generator controls and tune playable defaults - #238

Merged
genixpro merged 2 commits into
masterfrom
codex/map-generator-defaults
Sep 10, 2026
Merged

genixpro merged 2 commits into
masterfrom
codex/map-generator-defaults

Conversation

@genixpro

@genixpro genixpro commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Random-map defaults now leave more grassy building room while retaining each generator's shape. The lobby, map editor, command-line stress tool and study tooling share one definition of generator names, available controls, ranges, steps and defaults.

Stacked on #237 (codex/custom-game-ux, 82943d9). The diff contains only the generator follow-up.

Changes

  • Tune all eight procedural presets. Against Redesign custom-game lobby with automatic maps, teams, and rules #237's lobby settings, immediately free tiles rise from 9.8% to 40.7% for River and 9.2% to 57.1% for Crater Lakes.
  • Add measured lake-size, channel-width, neutral-island, island-size and bridge-width controls. Both UIs use the same definitions, remember terrain settings per mode, and share dimensions/colony/worker settings. Grass 75 and island size 65 are selectable; bounds and five-unit steps agree everywhere.
  • Remove ineffective wheat/wood/stone/algae ratio widgets; retain working fruit controls. Restore legacy resource placement and fix the invalid team index during island base placement.
  • Rename the legacy modes to Shattered Coast and Rugged Archipelago, with matching labels in all 33 languages.
  • Export the compiled catalog to generate the control reference and chart labels. Add reproducible study seeding without changing normal UI random seeding.

Validation

  • Release game build, shared-control regression, and existing custom-setup headless/native SDL tests pass, including previews, controllers, saves and replays.
  • Every selectable editor value, lobby dropdown/stepper interactions, per-mode memory, and descriptor serialization checked. Add shared-control coverage to Linux CI.
  • 24,000 fixed-seed attempts: 1,000 per generator for tuned, previous lobby, and previous editor settings. 72 independently repeated seed/configuration pairs match. Inspect 24 final map previews plus native UI captures.
  • Strict translation audit, English-fallback regression, Python syntax and diff checks pass.

Both baseline cohorts include the same legacy correctness fixes. Placement failures remain: River has 18/1,000 (versus lobby 4 and editor 145), and Shattered Coast 14/1,000 (versus lobby 0 and editor 2), alongside substantially better building room and starting-resource access. Small crowded maps remain a limitation; this is not a full-match balance study.

Coverage comparison

Generated reports, study data and screenshot files are excluded from the branch tip and final source changes. Existing image embeds reference earlier review commits; future artifacts stay outside tracked source.

Base automatically changed from codex/custom-game-ux to master September 10, 2026 16:57
@genixpro
genixpro force-pushed the codex/map-generator-defaults branch from 9e9f0f1 to b74f6df Compare September 10, 2026 21:36
@genixpro

Copy link
Copy Markdown
Contributor Author

Rebased onto current master (e3a01b051). This branch forked from codex/custom-game-ux at 82943d96e, but #237 merged with more commits added after that point (and was squash-merged, so 82943d96e never became a real ancestor of master). A plain git rebase tried to replay this branch's full pre-fork history against master and produced conflicts across every translation file; I instead used git rebase --onto master 82943d96e to replay only this branch's own 3 commits, which reduced it to genuine, small conflicts:

  • All data/texts.*.txt and data/texts.keys.txt conflicts were pure append/append (both sides added unrelated new keys at the end of the file) — resolved by keeping both additions.
  • src/SConscript was two unrelated new build-target blocks added at the same location — resolved by keeping both.
  • The trailing commit (9e9f0f19d, the Cortex-difficulty-label fix) dropped cleanly during the rebase — its change was already upstream via #237's later commits, confirmed by diff before pushing.

Verification on this head (b74f6dffb):

  • Full scons -j8 release=1 server=0 client build is clean.
  • scons -j8 release=1 server=0 map-generator-defaults-test map-generator-study builds clean.
  • MapGeneratorDefaultsTest . passes (shared presets, ranges/steps, editor values, lobby/editor mode memory, serialization and validation).
  • MapGeneratorStudy --catalog runs and emits the expected control catalog for all 9 generator methods.

Requesting review from @Giszmo and @kylelutze — this is still marked draft; let me know if it should come off draft. No merge performed.

genixpro added a commit that referenced this pull request Sep 10, 2026
#238 (already merged) added CustomGamePreferences.h, which serializes
CustomGameSetup::generator by taking Sint32 MapGenerationDescriptor::*
member pointers directly. #240 replaced generator's type with
GenerationRequest, whose method-specific options live in a generic
std::map<std::string,int> instead of fixed struct fields — an
architecture change, not a rename — so the file no longer compiled.

- CustomGamePreferences::encode()/decode() now convert through
  toLegacyDescriptor()/fromLegacyDescriptor() (the compatibility
  adapter #240 already built for exactly this kind of interop), so the
  on-disk wire format and its corruption-recovery bounds are unchanged.
  decode()'s method-validity check now asks the live GeneratorRegistry
  instead of a hardcoded 1-8 range, so it stays correct as generators
  are added or retired.
- Widened several fields() bounds (terrain weights 0-64 -> 0-100,
  riverDiameter's max 64 -> 65, oldIslandSize 1-64 -> 1-70) to match
  the modular registry's current ranges. These are approximate,
  same as before: the reused legacy fields (e.g. riverDiameter also
  stands in for lake size/channel width/bridge width) don't have one
  true bound, so this is a safe envelope, not a tight per-method one.
  Without this, decode() could reject a preferences file that a normal
  save legitimately produced (oldIslandSize's own default already
  exceeded the old 1-64 bound for any method other than Isles/Old
  Islands).
- Found and fixed a related crash bug in the compatibility adapter
  itself while tracing this: Lattice and Maze register wheat/wood/
  stone/algae controls with no entry in legacyField()'s mapping table,
  so converting either method through toLegacyDescriptor/
  fromLegacyDescriptor threw an uncaught std::invalid_argument. Added
  the four missing mappings (they match pre-existing legacy struct
  fields exactly) and changed every other option with no legacy slot
  (loopiness, home-radius, cell-size, ...) from throwing to falling
  back to its control's default value, so a newer generator's full
  option set can never crash this adapter again.
- test/CustomGameSetupHarness.cpp: preferencesModel()/preferencesScreen()
  built a GenerationRequest via the same member-pointer approach;
  updated both to build a temporary MapGenerationDescriptor and convert.
  Switched preferencesModel()'s method from Old Islands to Crater Lakes
  (one of the four modern height-map generators that still exposes a
  repeat-landscape control; Old Islands never did in the new registry,
  so it always round-tripped back to 0). The per-field assertions in
  preferencesScreen() now compare against the same achievable
  conversion rather than raw field maximums, since only the options a
  method actually registers survive a GenerationRequest round trip.

Verified: full scons -j8 release=1 server=0 client build is clean.
CustomGameSetupHarness passes in default, preferences-write and
preferences-read modes. MapGeneratorDefaultsTest and
MapGeneratorStudy --catalog also pass, unaffected by the adapter fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFxsZmLM4qsovemHqDrHGP
@genixpro
genixpro marked this pull request as ready for review September 10, 2026 23:23
@genixpro
genixpro merged commit 8b8c934 into master Sep 10, 2026
3 checks passed
@genixpro
genixpro deleted the codex/map-generator-defaults branch September 10, 2026 23:23
genixpro added a commit that referenced this pull request Sep 10, 2026
#238 (already merged) added CustomGamePreferences.h, which serializes
CustomGameSetup::generator by taking Sint32 MapGenerationDescriptor::*
member pointers directly. #240 replaced generator's type with
GenerationRequest, whose method-specific options live in a generic
std::map<std::string,int> instead of fixed struct fields — an
architecture change, not a rename — so the file no longer compiled.

- CustomGamePreferences::encode()/decode() now convert through
  toLegacyDescriptor()/fromLegacyDescriptor() (the compatibility
  adapter #240 already built for exactly this kind of interop), so the
  on-disk wire format and its corruption-recovery bounds are unchanged.
  decode()'s method-validity check now asks the live GeneratorRegistry
  instead of a hardcoded 1-8 range, so it stays correct as generators
  are added or retired.
- Widened several fields() bounds (terrain weights 0-64 -> 0-100,
  riverDiameter's max 64 -> 65, oldIslandSize 1-64 -> 1-70) to match
  the modular registry's current ranges. These are approximate,
  same as before: the reused legacy fields (e.g. riverDiameter also
  stands in for lake size/channel width/bridge width) don't have one
  true bound, so this is a safe envelope, not a tight per-method one.
  Without this, decode() could reject a preferences file that a normal
  save legitimately produced (oldIslandSize's own default already
  exceeded the old 1-64 bound for any method other than Isles/Old
  Islands).
- Found and fixed a related crash bug in the compatibility adapter
  itself while tracing this: Lattice and Maze register wheat/wood/
  stone/algae controls with no entry in legacyField()'s mapping table,
  so converting either method through toLegacyDescriptor/
  fromLegacyDescriptor threw an uncaught std::invalid_argument. Added
  the four missing mappings (they match pre-existing legacy struct
  fields exactly) and changed every other option with no legacy slot
  (loopiness, home-radius, cell-size, ...) from throwing to falling
  back to its control's default value, so a newer generator's full
  option set can never crash this adapter again.
- test/CustomGameSetupHarness.cpp: preferencesModel()/preferencesScreen()
  built a GenerationRequest via the same member-pointer approach;
  updated both to build a temporary MapGenerationDescriptor and convert.
  Switched preferencesModel()'s method from Old Islands to Crater Lakes
  (one of the four modern height-map generators that still exposes a
  repeat-landscape control; Old Islands never did in the new registry,
  so it always round-tripped back to 0). The per-field assertions in
  preferencesScreen() now compare against the same achievable
  conversion rather than raw field maximums, since only the options a
  method actually registers survive a GenerationRequest round trip.

Verified: full scons -j8 release=1 server=0 client build is clean.
CustomGameSetupHarness passes in default, preferences-write and
preferences-read modes. MapGeneratorDefaultsTest and
MapGeneratorStudy --catalog also pass, unaffected by the adapter fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFxsZmLM4qsovemHqDrHGP
genixpro added a commit that referenced this pull request Sep 10, 2026
Rebased onto master now that #238 has merged. That surfaced a real compile
break: the harness still called Map::oldMakeIslandsMap/Game::oldMakeIslandsMap,
which the registry-driven generator rewrite removed.

Route it through MapGenerator::generateMap(Game&, const MapGenerationDescriptor&,
seed) instead, which owns map sizing and the game association internally. Seed
explicitly since generation determinism no longer follows the global sync-rand
state.

Also start from setMethodDefaults() rather than hand-picked constants: #238's
defaults tuning tightened several control ranges (island-size moved to 50-70,
the shared "workers" control caps at 8), so the harness's old literals
(oldIslandSize=35, nbWorkers=48) now fail request validation. Defaulting first
and overriding only what this decorative colony actually needs to differ keeps
it from rotting the same way again as ranges keep moving.

Verified: full client and menu-colony-harness build clean; `check`, `navigation`,
and `generate` subcommands all pass (generate grows the colony from 8 to 56
units over the same 12,000-tick warmup, confirming the reduced starting worker
count doesn't defeat the decorative intent).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Le23P4h9QuDjwExuK6HU96
Giszmo added a commit that referenced this pull request Sep 14, 2026
Removed again in the next commit; the blobs stay reachable by SHA so the raw
URLs in the PR body keep rendering. Repository convention, see PR #238.

Opus 5 helped authoring this commit.
genixpro added a commit that referenced this pull request Sep 14, 2026
…ors (#240)

* Modularize map generation and add new generators

* Sort map generators by quality

* Refine map generator quality order

* Make Contested Commons the first and default generator

* Fix rectangular map previews and wrapped colony start coordinates

* Keep review reports and generated screenshots out of the repository

* Bridge CustomGamePreferences through the legacy generator descriptor

#238 (already merged) added CustomGamePreferences.h, which serializes
CustomGameSetup::generator by taking Sint32 MapGenerationDescriptor::*
member pointers directly. #240 replaced generator's type with
GenerationRequest, whose method-specific options live in a generic
std::map<std::string,int> instead of fixed struct fields — an
architecture change, not a rename — so the file no longer compiled.

- CustomGamePreferences::encode()/decode() now convert through
  toLegacyDescriptor()/fromLegacyDescriptor() (the compatibility
  adapter #240 already built for exactly this kind of interop), so the
  on-disk wire format and its corruption-recovery bounds are unchanged.
  decode()'s method-validity check now asks the live GeneratorRegistry
  instead of a hardcoded 1-8 range, so it stays correct as generators
  are added or retired.
- Widened several fields() bounds (terrain weights 0-64 -> 0-100,
  riverDiameter's max 64 -> 65, oldIslandSize 1-64 -> 1-70) to match
  the modular registry's current ranges. These are approximate,
  same as before: the reused legacy fields (e.g. riverDiameter also
  stands in for lake size/channel width/bridge width) don't have one
  true bound, so this is a safe envelope, not a tight per-method one.
  Without this, decode() could reject a preferences file that a normal
  save legitimately produced (oldIslandSize's own default already
  exceeded the old 1-64 bound for any method other than Isles/Old
  Islands).
- Found and fixed a related crash bug in the compatibility adapter
  itself while tracing this: Lattice and Maze register wheat/wood/
  stone/algae controls with no entry in legacyField()'s mapping table,
  so converting either method through toLegacyDescriptor/
  fromLegacyDescriptor threw an uncaught std::invalid_argument. Added
  the four missing mappings (they match pre-existing legacy struct
  fields exactly) and changed every other option with no legacy slot
  (loopiness, home-radius, cell-size, ...) from throwing to falling
  back to its control's default value, so a newer generator's full
  option set can never crash this adapter again.
- test/CustomGameSetupHarness.cpp: preferencesModel()/preferencesScreen()
  built a GenerationRequest via the same member-pointer approach;
  updated both to build a temporary MapGenerationDescriptor and convert.
  Switched preferencesModel()'s method from Old Islands to Crater Lakes
  (one of the four modern height-map generators that still exposes a
  repeat-landscape control; Old Islands never did in the new registry,
  so it always round-tripped back to 0). The per-field assertions in
  preferencesScreen() now compare against the same achievable
  conversion rather than raw field maximums, since only the options a
  method actually registers survive a GenerationRequest round trip.

Verified: full scons -j8 release=1 server=0 client build is clean.
CustomGameSetupHarness passes in default, preferences-write and
preferences-read modes. MapGeneratorDefaultsTest and
MapGeneratorStudy --catalog also pass, unaffected by the adapter fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFxsZmLM4qsovemHqDrHGP

* Fix MenuColonyHarness for the modularized generator API

Rebased onto master now that #238 has merged. That surfaced a real compile
break: the harness still called Map::oldMakeIslandsMap/Game::oldMakeIslandsMap,
which the registry-driven generator rewrite removed.

Route it through MapGenerator::generateMap(Game&, const MapGenerationDescriptor&,
seed) instead, which owns map sizing and the game association internally. Seed
explicitly since generation determinism no longer follows the global sync-rand
state.

Also start from setMethodDefaults() rather than hand-picked constants: #238's
defaults tuning tightened several control ranges (island-size moved to 50-70,
the shared "workers" control caps at 8), so the harness's old literals
(oldIslandSize=35, nbWorkers=48) now fail request validation. Defaulting first
and overriding only what this decorative colony actually needs to differ keeps
it from rotting the same way again as ranges keep moving.

Verified: full client and menu-colony-harness build clean; `check`, `navigation`,
and `generate` subcommands all pass (generate grows the colony from 8 to 56
units over the same 12,000-tick warmup, confirming the reduced starting worker
count doesn't defeat the decorative intent).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Le23P4h9QuDjwExuK6HU96

* Guarantee reachable starting wheat and wood, fix old-random's wood-poor bias

Building on #240's modular generator rewrite: swamp, river, islands, crater
lakes, old-random and old-islands all pick each team's resources through a
step that has no idea where any *other* team ended up, or in old-random/
old-islands' case scores compass directions independently per team without
comparing outcomes across teams. A colony's proximity to wheat and wood was
left almost entirely to how the (still resource-blind) starting-position
search happened to place it, so one team could land next to both while
another got only one, or occasionally neither within playable range.

Add shared/Resources::guaranteeStartingResources, which floods outward from
each team's boot tile through the same walkable-space search used to judge
distance (isHardSpaceForGroundUnit), so anything it places is reachable by
construction rather than merely straight-line close. It tops up only teams
that are missing wheat or wood within the range map_generator_study.py
already scores as viable; already-served teams are untouched. Wired into
the four generators that share Terrain::generateHeightField, and into
old-random/old-islands after their own start placement.

Also fix a starvation bug in old-random's resource search: its 8-direction
scan hardcoded a 4th slot to CORN, so every colony got a guaranteed second
wheat deposit while wood only ever got one. The 4th slot is now awarded to
whichever of the two came out narrower for that colony.

Extend test/MapGeneratorStudy.cpp's tuning output with best_wheat_distance/
best_wood_distance (previously only the worst-served team's distance was
tracked) so tools/map_generator_study.py can size the gap between a map's
best- and worst-served colony, not just its worst case in isolation.

Validated with 200 fixed seeds per generator via map_generator_study.py
(artifacts not committed, matching this directory's own convention):
generation success rate is unchanged for every generator (including
old-random and old-islands' existing ~4-5% baseline failure rates); the
wheat-access gap between a map's best- and worst-served colony drops for
swamp, river, islands and crater lakes, and old-random's wood gap and
worst case both drop by roughly two-fifths. Old-random's wheat gap grew in
the same run and a handful of colonies on constrained terrain (river was
the clearest case) can still end up with no reachable wheat or wood at
all; both are documented as open follow-ups in
docs/map-generators/FRAMEWORK_UPGRADES.md; a farthest-point starting-
position search was tried against the latter and measured worse on river
and islands, so it was not kept.

This is a balance-affecting change to established generators and needs a
maintainer's sign-off per this repo's review rules, not just this PR's own
validation numbers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW

* Detect and clear resource walls that seal off a team's starting pocket

Follow-up to 777b748. That commit's guaranteeStartingResources fixed most
of the fairness gap but left river with a residual ~5% of colonies
reaching no wheat or wood at all, and left old-random's imbalance direction
flipped rather than closed. Investigated the river failures directly (map
dumps + reachability analysis, not just aggregate metrics this time): a
colony's boot tile was landing on a stretch of perfectly good, well
connected land — confirmed by re-flooding the same tile through terrain
alone, ignoring resources, and finding a landmass orders of magnitude
larger than what the resource-respecting flood could reach. A ground unit
can't walk onto a tile carrying a resource (Map::isHardSpaceForGroundUnit
excludes them), and the noise-band resource painting has no idea what it
might wall in, so it was sealing colonies into small pockets on their own
otherwise-fine landmass.

guaranteeStartingResources now detects this directly: when a team's
resource-respecting reachable area is suspiciously small, it re-floods the
same tile blocked only by water (ignoring resources) and diffs the two
results to find exactly the resource tiles forming the wall's face —
clearing only those, not a surrounding neighborhood, and iterating a
bounded number of times in case a wall is thicker than one tile. A colony
on a genuinely small spot (a real islet on a water-heavy map) is left
alone: there's no larger area on the other side of nothing, so the
terrain-only flood finds nothing bigger and the check is a no-op by
construction.

First attempt at this used a blunt fixed-radius clear around the boot tile
whenever the pocket looked too small, gated only on the team actually
being under-served. It worked for river but silently regressed swamp
(whose terrain legitimately includes small real islands): demolishing a
25x25 tile neighborhood around a "small but actually fine" pocket destroys
perfectly good nearby deposits for no gain when there is no larger
landmass to reach. The precise wall-tracing approach here has no such
failure mode, validated below.

Reconfirmed with the same 200-fixed-seed methodology as 777b748: river's
rate of a colony reaching no wheat or wood at all drops from ~5% to ~3%
(the earlier blunt version reached 0% on this seed range but at swamp's
expense — see above), with the remaining cases past this guarantee's own
search radius. Swamp's numbers now improve slightly rather than regressing
(the false-blame that motivated the blunt version's guard is gone: the
gate is precise instead of merely permission-checked). Crater lakes,
islands, old-random and old-islands are unaffected (the mechanism only
fires where floodReach and a terrain-only flood actually disagree).
Generation success rate is unchanged everywhere. Confirmed the existing
map-generator-defaults-test and a full non-server engine build still pass.

Old-random's wheat/wood imbalance (flipped, not fixed, by 777b748) and the
remaining few percent of unreachable river colonies stay open follow-ups,
called out in docs/map-generators/FRAMEWORK_UPGRADES.md along with why a
join symmetric compass-slot assignment was tried and not kept for the
former.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW

* Choose colony sites after resources exist, and choose them for equality

The four height-field generators picked colony sites before any resource
was on the map, so a site could only be judged by the shape of the grass
under it: largest contiguous grass rectangle to the first colony, and
everyone after it taking what was left. Every fairness fix so far has been
downstream repair of that decision. This changes the decision.

The ordering turned out not to be a constraint at all. Nothing between the
placement pass and the resource pass reads a boot position or draws from a
random stream, so the two simply swap, and placement can then see what it
is actually choosing between.

Measured the headroom before building anything, via a new `headroom` mode
in the study tool: score every legal site against a single multi-source
flood per resource (one flood answers the question for the whole map, so
scoring a site is a lookup rather than its own search), then find the
narrowest score window still holding enough mutually distant sites. Result:
every map sampled, on every generator, already contained a placement where
all colonies were equally served. The maps were never unfair. The placement
was throwing away fairness the map already had.

shared/StartingPositions::chooseBalancedStarts now picks sites after the
resource pass. A site scores as its *worse* primary resource, since a
colony beside wood but a long walk from wheat is not a good start; sites
are sorted and the narrowest qualifying window wins, scanned from the
low-score end so ties settle in favour of a set that is not merely equal
but good. The legacy search remains as a fallback for maps where no set of
sites can reach both resources.

Across 200 fixed seeds per generator, versus the pre-existing behaviour:
  gap between best- and worst-served colony  9.5-19.0  ->  3.5-4.0 tiles
  worst-served colony's walk to a resource  12.0-18.5  ->  3.5-4.2 tiles
  colonies reaching no wood or wheat at all  up to 5.6% ->  0%
  colonies clearing the study tool's viability bar        all of them
The worst-served colony is now better off than the *luckiest* colony was
before. Verified at 2, 6, 8 and 12 colonies as well (gap 2.4-7.4, widening
with colony count as expected, no generation failures introduced at any
count). Success rates, resource totals and colony separation (30-72 tiles
apart on a 128x128 map) are unchanged. map-generator-defaults-test and a
full non-server engine build pass.

Old-random and old-islands deliberately keep their own placement: they put
resources relative to each boot tile, so moving a colony does not bring its
resources along. The same headroom measurement puts their achievable gap at
8.0 and 0.9 tiles, and old-islands already sits at 0.9.

This changes how these four generators play, and that part is a
maintainer's call rather than a metric's: colonies now start snug against
their wood and wheat instead of in open ground, and buildable room within
reach of the worst-served colony drops 14-28% (still 405-642 free building
sites against a viability bar of 16). Generator revisions are bumped, so a
given seed no longer produces the map it did before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW

* Score colony sites as they will be built, not as the bare map looks

Placement picks a site for its walk to wood and wheat, but a colony
rewrites its own surroundings the instant it is built: placeStarts()
clears a five by seven box of resources to make room, the swarm itself
becomes four by four tiles of obstacle, and the workers spawn on the row
above the boot tile rather than on it. Scoring against the bare map
therefore credited a site with deposits it was about to destroy and paths
it was about to block.

That was measurable, not theoretical. Instrumenting the search on one seed
showed it choosing four sites scored wood 0, wheat 0 apiece -- exactly
equal -- which the finished map then spread across three tiles, because
how much each site loses to its own construction depends on the shape of
the resources around it.

chooseBalancedStarts now shortlists on the cheap bare-map distance, which
can only understate the built cost and so is a sound filter, then re-scores
the shortlist by simulating the finished colony: clearing box excluded,
swarm footprint impassable, flood starting from the worker row. The offsets
mirror placeStarts() rather than approximating it.

Against the previous commit, over 200 fixed seeds per generator:
  gap between best- and worst-served colony  3.5-4.0  ->  0.92-1.02 tiles
  worst-served colony's walk                 3.5-4.2  ->  2.0-2.3 tiles
Better on both counts at once, and level with old-islands, the
fair-by-construction benchmark. Holds at 2, 8 and 12 colonies (gap
0.35-4.70, widening with count as expected, no failures at any count).
Buildable room, resource totals and colony separation are unchanged.
map-generator-defaults-test and a full non-server engine build pass.

A first attempt instead excluded every site whose deposits sat inside the
clearing box. It tightened the gap to 2.8-3.1 but pushed every colony
roughly twice as far from its resources (worst walk 3.5 -> 6.6 tiles),
trading what matters for what was being measured. Not kept.

Generation costs 63-110ms against 11-14ms, nearly all of it the shortlist
re-scoring, and the first working version cost 1400ms until the visited set
stopped being a linear scan. Imperceptible for a lobby generating one map.
Halving the shortlist halves the cost but loses a third of the gain on
river, so the shortlist stays at 900.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW

* Compute fertility as the growth probability it actually is

Map::growResources decides whether a wheat or wood tile expands with

    dwax = (syncRand()&0xF) - (syncRand()&0xF);   // likewise dway
    expand = isWater(x+dwax, y+dway) && !isSand(x-dwax, y-dway);

The difference of two uniform draws from {0..15} has PMF (16-|d|)/256, so
the chance a tile expands is a triangular kernel summed over every water
tile whose mirror through the tile is not sand. FertilityCalculator fit a
curve to the first half of that and dropped the second: its weights were
int(4.2*sqrt((15-|dx|)*(15-|dy|))) and it never looked at sand at all,
overstating exactly the tiles where wheat will not in fact come back.

Fertility::Field evaluates the real thing in closed form. A length-16
forward box followed by a length-16 backward box is exactly the triangular
kernel, so the water term is four linear passes rather than 961 taps per
grass tile, and the sand term is one stamp per sand tile. Measured over 20
seeds on each of eight generators it runs about ten times faster than the
kernel it replaces (the sand correction, not the convolution, is what it
spends its time on) and correlates 0.89-0.997 with it; shattered-coast,
the sandiest of them, is both the least correlated and the slowest, which
is the difference being paid for.

The field takes plain masks and no Map, so map generation can score a
candidate's growth potential without a live game. FertilityCalculator
keeps its API, its deposit-reachability gate and fertilityMaximum, and
clamps to the Uint16 that Tile::fertility holds; the scale is now 65536,
which the overlay normalises away and MapIO stores per tile as before.

The implementation comes from ExactFertilityCache in AIMaximaFarming on
the Maxima branch, extracted here so both callers share one field.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW

* Score the start each colony actually got, not just its walk

Placement equalises walking distance to wood and wheat, and on four of
these generators that is now down to two tiles for the worst-served
colony. It is also nearly all the metric has left to give: sampling twenty
maps and keeping the fairest shaves two tenths of a tile off a gap of one.

Distance was always a thin account of a starting position. A deposit at
the door that runs dry, a colony with no room to build, one boxed between
two rivals, and one on ground where wheat never grows back all look alike
to it. StartQuality measures six things per colony on the finished map --
where the swarm is built, its clearing cleared and the workers standing
where they will actually start walking -- and folds them into one number
per map: the weakest colony's quality, gated by how evenly the map shared
quality out, worst * (worst/best)^k.

Fertility carries the most weight of the six because it is the one that
decides whether a colony's wheat comes back at all; the others describe
what it starts with, fertility describes what it keeps. Every factor is
normalised against a fixed reference rather than the map's own best
colony, because the point is to rank candidate maps against each other and
a per-map normalisation scores every map alike. The two distances use the
viability bars the study tool already scores against; the other four have
no such bar, so they sit near the ninetieth percentile of what the twelve
generators actually produce, measured over 2400 maps.

Scoring costs 2.5-5.4ms against 11-61ms to generate, consumes no random
stream, and never rejects a map -- it ranks. Map hashes are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW

* Keep the best of five rolls instead of the first that fits

The lobby already generated up to five maps and kept the first that
succeeded, because some rolls cannot fit every starting colony. Among the
rolls that do fit, some still hand one colony far better ground than
another, and now that there is a score for that, the same loop can keep
the best rather than the first.

Five is a budget, not a knee. Keeping the best of K is the maximum of K
draws from a distribution, so it grows like sqrt(2 ln K): it improves
forever and flattens gradually, and bootstrapped over 200 scored maps per
generator all twelve trace that same curve to within a few percent -- three
candidates capture 44% of what fifty give, five 59%, eight 70%, twenty 87%.
What picks the cutoff is that the lobby generates on the UI thread behind a
500ms debounce: at 61ms a candidate on crater-lakes, five rolls cost 306ms
where eight would cost 489ms and spend the whole budget.

The editor owns its Game and cannot hold a spare, so it takes the winning
seed from GenerationService::bestSeed and regenerates it. That is sound
because generation is deterministic and the score is a pure function of the
finished map; map-generator-defaults-test asserts both, along with the
chosen seed being a candidate no other candidate outscores.

Optimising a composite could have pulled colonies away from their resources
to chase fertility or elbow room. Measured, it does not: the worst-served
colony's walk holds at about two tiles on the four height-field generators
and improves by four on shattered-coast, while fairness rises from
0.81-0.94 to 0.92-0.97 everywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rc6FTwBEB6LKNUmamX9wWW

* Cache the per-tile hard-space test chooseBalancedStarts already proved static

Profiling map generation under load (users noticed the "keep the best of five
rolls" work from the previous commit made map generation feel much slower)
found chooseBalancedStarts itself, plus Map::isHardSpaceForGroundUnit called
from inside it, accounting for 74-84% of total generation time on swamp,
river, islands and crater lakes: two whole-map resource floods each call it
once per tile, and then every one of up to 900 shortlisted candidate sites
re-simulates its own local flood calling it again, up to ~4225 tile visits
each in the worst case.

That work was entirely redundant. Every call site in this file uses the same
two constants (canSwim=false, team mask=0), which makes checkTile's forbidden-
area test a no-op, and chooseBalancedStarts runs before placeStarts() ever
places a building, so getBuilding() is NOGBID everywhere it looks. Under
those invariants isHardSpaceForGroundUnit(x, y, false, 0) reduces to
!isResource(x, y) && !isWater(x, y) — a pure function of terrain/resource
state that cannot change across the whole search.

buildHardSpaceGrid() computes that once into a flat std::vector<uint8_t>
before the two distanceToResource() floods; all three flood loops (the two
global ones and scoreAsBuilt's per-site one) read the cached byte instead of
calling through checkTile's several field accessors. distanceToResource's
distance vectors and the per-site visited/visitStamp array are narrowed from
int to 16 bits alongside this — every distance this engine can produce fits
comfortably, and the arrays these floods touch millions of times are half
the size to move through cache.

This changes nothing about which sites get chosen or how they get scored,
only how the unchanged answer is computed: MapGeneratorStudy's per-tile
terrain/resource hash is bit-identical before and after on matching seeds
across every affected generator. Measured 28-31% faster end to end on swamp,
river, islands and crater lakes (interleaved before/after binaries, 60
generations per generator, to separate the effect from this machine's own
run-to-run noise) with zero measurable change on the nine generators that
don't call chooseBalancedStarts, which is the expected result and also rules
out the noise itself producing the improvement.

Verified: full scons client build and scons -C test suite (198 cppunit
cases) pass. MapGeneratorStudy succeeds across all 13 generators at 5 seeds
each, both before and after. FertilityFieldTest and MapGeneratorDefaultsTest
are unaffected (this file's callers are unrelated to either).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Narrow FertilityField's single-axis intermediates to the range they use

Investigated as part of the same profiling pass as the previous commit:
Fertility::Field::rebuild is the dominant cost on Lattice and Maze (57% and
39% of total generation time), but unlike chooseBalancedStarts this is not
redundant recomputation — Fertility::forMap has exactly one caller
(scoreStarts) and runs once per roll, confirmed via its full call chain.

Both generators are simply water/sand-heavy by design (thick maze corridors,
real shorelines): instrumentation shows ~6,100 water and ~1,260 sand tiles
for Lattice and ~4,100/~1,350 for Maze against a 16,384-tile map, and the
sand-correction path costs one 31x31 weighted stamp per sand tile no matter
how the adaptive rule picks between it and the water-splat alternative.

first/second hold the box-blur passes' single-axis partial sums. The first
pass (a plain 16-wide box over 0/1 water values) tops out at 16; combined
with the second, backward pass into the full 1D triangular kernel it tops
out at that kernel's own weight sum, 256; the vertical passes repeat the
same shape against those values, topping out at 16*256=4096. Both fit
uint16_t with no precision loss — the final `fertility` array still needs
32 bits, since its theoretical max (256*256=65536) is one past uint16_t's
range.

This is exact and safe, but measured negligible impact on Lattice/Maze
specifically (their cost is the sand-correction term, which these arrays
aren't part of). Worked through algebraically, that term reduces to
sum_d weight(d) * water(target+d) * sand(target-d) - a bilinear
cross-correlation between two different fields, not the single-field sum
the box-blur trick relies on, so it does not have an equivalent separable
speedup; only FFT-based convolution would change its asymptotic cost, and
Fertility::Field is shared with FertilityCalculator (real gameplay
fertility), so any such rewrite needs to preserve that caller's output
exactly too. Left as an open follow-up rather than attempted here.

Verified: full scons client build and scons -C test suite (198 cppunit
cases, including FertilityFieldTest) pass with no narrowing warnings.
MapGeneratorStudy succeeds across all 13 generators and produces
bit-identical per-tile terrain/resource hashes before and after on matching
seeds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Hoist named RNG-stream lookups out of Shattered Coast and Rugged Archipelago's hot loops

Continuing the same profiling pass as the previous two commits: Shattered
Coast (old-random) and Rugged Archipelago were the slowest and third-slowest
of all 13 generators, and neither bottleneck was in their own terrain logic.

GenerationContext::stream(name) looks up a named std::mt19937 in a
std::map<std::string, std::mt19937>, computing a hash over the name and
walking the tree on every call — cheap on its own, but both generators call
it by string literal from deep inside per-tile loops that run tens of
thousands of times per generation. Shattered Coast's simulateRandomMap
(itself invoked repeatedly per smoothing iteration to balance water/sand/
grass ratios) draws from "simulation" up to five times per tile across every
w*h-tile map it simulates; its terrain() draws from "terrain" the same way
across its own patchwork and smoothing passes. Rugged Archipelago's
island-growing and beach passes draw from "terrain" identically. In every
one of these loops the stream name is a compile-time constant that never
changes for the life of the function, so the repeated lookup was pure
overhead paid on every single draw.

Both functions now look their stream up once into a std::mt19937& at the
top and draw from that reference throughout, instead of calling
context.stream(name) again for every draw. This draws from the exact same
underlying generator in the exact same order, so it changes no random
number either function produces: MapGeneratorStudy's per-tile terrain/
resource hash is bit-identical on matching seeds before and after.

Verified: full scons client build and scons -C test suite (198 cppunit
cases) pass. MapGeneratorStudy succeeds across all 13 generators at 5 seeds
each. Measured 50% faster on Shattered Coast and 31% faster on Rugged
Archipelago end to end (interleaved before/after binaries, 60-100
generations per generator), with ~0% change on two unaffected generators
run the same way, ruling out this machine's run-to-run noise as the source
of the improvement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Fix column-major grid traversal and pointer-chasing data structures in region splitting

Profiling identified Contested Commons, Concrete Islands and Isles as now the
slowest generators, dominated by the older Voronoi-style point-dispersion
code in shared/Regions.cpp and shared/Distances.cpp. Investigated with cache
behavior specifically in mind.

Exact fixes (same output, only how it's computed - verified by bit-identical
per-tile terrain/resource hashes on matching seeds, every generator):

- computeDistances' BFS used std::queue<int> (std::deque-backed, growing by
  separately heap-allocated blocks) even though every cell is enqueued at
  most once (the `side == 0` guard prevents a second push). A flat,
  preallocated w*h-sized array makes an exact FIFO out of sequential writes
  instead of block-to-block pointer chasing.
- splitUpArea's per-region frontier is inserted at a genuinely random
  position on purpose - that randomness is what gives the flood its organic
  shape, not an accident to remove - but std::vector<int> supports the
  identical random-position insert/erase via a contiguous memmove, without
  std::list's per-node heap allocation and pointer chasing on every single
  insert and on the std::advance that finds where to insert.
- adjustHeightmapFromPerlinNoise and computeAverageDistance are a pure
  per-cell transform and a commutative sum respectively, so nesting y
  outside x costs nothing and walks both the grid and HeightMap's own
  identically row-major _map array with their grain instead of across it.
- getAllPoints, getAllOtherPoints, findBorderPoints, and splitUpPoints's own
  two internal scans all walked a row-major grid as `for x { for y {...} }`
  - a full-row stride on every step - for no reason but habit, except two of
  these results (possible[n], startingPoints[n]) get indexed by a random
  draw, so a plain loop-order swap would silently pick a different point for
  the same seed. collectPointsColumnOrder gets both: a row-major counting
  pass sizes each output column, then a second row-major pass drops each
  point into its precomputed slot, landing every point in the exact x-major,
  y-minor order the original nested loop produced without ever striding
  across the grid to do it. splitUpPoints' single-pass "reset the candidate
  list on every strict improvement" site search reduces to the same set
  every time regardless of how it's computed (traced by hand, confirmed by
  the same hashes), so it is now an explicit two-pass max-then-collect using
  the same helper.

One further fix changes output for Contested Commons specifically:
splitUpPoints' PointSearch::WholeRegion mode (Contested Commons' own search,
not used by any other generator) scores every legal tile as a candidate
placement and keeps only a strict improvement, so whichever tied candidate
is reached earliest in scan order wins - the one shape here a loop-order
swap could not fix without changing anything. Measured directly across 7
seeds: 6 of 7 produce byte-identical maps regardless, and the one that
changed still converges to a placement of the same quality by the search's
own metric (already documented as bounded best-response, not a guaranteed
optimum). PointSearch::Local - used far more broadly, including as
swamp/river/islands/crater lakes' rare legacy fallback - keeps its exact
original scan order untouched: its window is 7x7, too small for traversal
order to matter, and touches far more generators than this fix was worth
risking. Contested Commons' revision bump follows in the next commit.

Verified: full scons client build and scons -C test suite (198 cppunit
cases) pass. MapGeneratorStudy succeeds across all 13 generators at 9 seeds
each. Measured (interleaved before/after binaries, repeated 3x to get past
this machine's run-to-run noise - an initial single-run reading overstated
the effect by more than 2x): Concrete Islands ~9% faster, Isles ~5-7%
faster, Contested Commons ~8-9% faster overall. Fjord Continent, which
touches none of this code, showed ~2% change over the same run, taken as
the noise floor rather than a real effect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Bump Contested Commons' revision for its WholeRegion cache-order fix

The previous commit's PointSearch::WholeRegion traversal-order change can
change which exactly-tied candidate site wins a placement, so a given seed
can produce a different (equally valid, by the search's own metric) map
than before. Following this repository's existing convention for
generation-output changes, bump the revision so that is visible rather than
a silent side effect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Finish replacing std::queue with preallocated flat FIFOs across the flood helpers

A follow-up sweep for every remaining std::queue/std::list/std::map in the
map generator code, not just the ones profiling had already pointed at.
Found three more BFS floods with the exact same shape as computeDistances'
earlier fix - each bounded to at most w*h enqueues by a dist[...] < 0 or
visited[...] == stamp guard, so a flat preallocated buffer is always enough
- that were missed the first time round:

- StartingPositions.cpp's own distanceToResource and scoreAsBuilt still used
  std::queue internally even after the hard[] caching work in an earlier
  commit only replaced the isHardSpaceForGroundUnit calls, not the queue.
  scoreAsBuilt compounds differently from the rest: it runs this same flood
  up to 900 times per chooseBalancedStarts call, so a fresh std::queue (a
  fresh std::deque, freshly allocated) 900 times over was the real cost, not
  each flood's own pushes. The fix reuses one pair of preallocated buffers
  across all 900 calls, resetting two indices between them instead of
  reallocating a container each time.
- Resources.cpp's floodReach and terrainOnlyReach (the resource-wall
  detector's reachability floods).
- StartQuality.cpp's walkFromWorkers.

Checked and left alone: Topology.cpp's own queue runs over Maze's cell
graph (dozens of nodes, not tiles), where the difference is unmeasurable,
and its std::map is exercised only by test code, not by any generator.
Also swept every other context.stream() call site beyond the two fixed in
the previous commit; the rest all draw well under a thousand times per
generation (fruit placement, layout shuffles), far too infrequent for the
map-lookup overhead to matter.

Verified: full scons client build and scons -C test suite (198 cppunit
cases) pass. MapGeneratorStudy succeeds across all 13 generators at 6 seeds
each, with bit-identical per-tile hashes on every generator (including
Contested Commons matching the previous commit's WholeRegion-affected
values exactly, seed for seed). Measured (interleaved before/after
binaries, repeated 3x, with two generators that never call
chooseBalancedStarts run the same way as a noise check): swamp ~14%,
islands ~14%, crater lakes ~18% faster; river's readings were too noisy
this round to quote a single number but moved the same direction every
time. Contested Commons and Fjord Continent - untouched by this change -
showed ~0% and ~2% respectively, the same noise floor as the previous
commit found.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Give Fjord Continent an ambient resource scatter to restore map personality

Playtesting raised a real complaint unrelated to the performance work in
recent commits: the new generators feel like they've lost personality,
with resources reading as small guaranteed blobs stuck next to bases
instead of a map with its own texture.

Traced this concretely (MapGeneratorStudy's dump= option, rendered and
inspected directly) rather than guessing. Fjord Continent's every resource
is a deliberate, counted placement: a home starter kit, fjord bank
resources on each side of every fjord, stone/fruit in the untouched core,
0-4 outlier islands, and a shoreline algae band - never a map-wide painting
pass. That leaves the whole continent interior between those points bare
grass. Rendered, it looks exactly like the complaint: a handful of isolated
single-tile dots on an otherwise empty landmass, next to Swamp or River's
rich, varied, noise-painted texture.

Lattice and Maze already solve the identical problem with scatterResources
- a light map-wide clump scatter - called before either generator's own
guaranteed per-team placements, so a guarantee is placed after and can
never be buried or bumped by the ambient layer. Fjord now does the same,
at a modest density (wood/corn/stone; algae is left at zero since the
shoreline band a few steps later already places it with a shape tuned to
the coastline, and scattering more over open water would just fight that).

This is a real, deliberate change to how the generator plays, not only its
output - the four generators whose colony placement changed earlier in
this PR got the same treatment, and the same reasoning applies here.
Revision moves to 2.

Contested Commons' resource layout was inspected the same way and left
alone: its solid, sharply-bordered zones are an explicit, already-reasoned
design in its own code ("real fields...not a light dusting", with zones
deliberately never bordering a same-role neighbor) - a different design
choice, not the same gap.

Verified: full scons client build and scons -C test suite (198 cppunit
cases) pass. Generation succeeds at realistic team counts (1, 2, 4, 8 - 0
failures across 20+ seeds each). At 12 teams - already a barely-supported
configuration before this change, failing 18 of 20 seeds on "no swarm
footprint fits inside the home region" with no scatter at all - it now
fails all 20; a real but marginal effect on a configuration that was not
reliable to begin with.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Populate Map::Tile::fertility during generation, not just on load or in the editor

Playing raised a real question: why does the in-game fertility overlay show
nothing on a freshly generated map? Traced to the source instead of assumed.

OverlayArea::compute's Fertility branch (src/OverlayAreas.cpp) only ever
copies whatever is already sitting in Map::Tile::fertility - it never
computes anything itself. The only two things that ever populate that
field are FertilityCalculator (the map editor's "compute fertility"
checkbox, and Game_io.cpp's load-time migration for save files older than
format version 63, which the comment there describes as "did not have
fertility computed with the map, but computed it live") and, now,
scoreStarts. Neither of the first two runs as part of generation, so every
map that comes out of any of the 13 generators - every custom or lobby
game - starts with an all-zero fertility field. This predates and is
independent of the recent switch to computing fertility via
Fertility::Field: it dates back to whenever version-63 saves started
shipping fertility baked into the file instead of computing it live, and
generation was simply never updated to do the same.

scoreStarts already builds a full Fertility::Field for every roll, purely
to score colonies against (see the previous fertility-related commits in
this branch). Stamping that same field into the map's own tiles - the same
clamp-to-Uint16 FertilityCalculator uses - is a byproduct of work already
being paid for, not new work, and scoreStarts is the only point in the
generation pipeline that already has a finished map (colonies built,
resources final) to stamp it onto. Takes Game& instead of const Game& to
do this; its only two callers (GenerationService::generate,
MapGeneratorStudy) already held a non-const reference, so this needed no
other changes.

Verified: full scons client build and scons -C test suite (198 cppunit
cases) pass. MapGeneratorStudy succeeds across all 13 generators at 5
seeds each. Confirmed directly (a temporary counter in the profiling
driver, since nothing else exposes this) that a freshly generated map now
carries a non-trivial fertilityMaximum and real per-tile values across
every generator checked, where it was unconditionally zero before.
Measured no change outside this machine's run-to-run noise on Lattice,
Maze or Swamp - the write is a single O(tile count) pass over data the
scoring loop already computed, not a new computation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Fix a generation-failure regression in Fjord's ambient scatter, and scatter along fjord banks too

The previous commit's ambient scatter (scatterResources, called right after
controlSand - before any team anchor point, home area or swarm existed) was
verified for generation success at the time, but not thoroughly enough: a
larger seed sample at 8 teams turned up 3 of 20 seeds failing "no swarm
footprint fits inside the home region" that succeeded without the scatter,
confirmed by direct before/after comparison on the same seeds. isResourceAllowed
refuses any tile a building or unit already occupies, but at the point the
scatter ran, none existed yet, so an early scatter clump could claim the one
tile a colony's swarm footprint needed with nothing downstream able to tell.

Fix: move the identical scatterResources call to run after every team's swarm
and workers are already placed, right before the fjord bank resources (which
already had to run last for the same class of reason). This changes nothing
about what the scatter places - setResource always overwrites regardless of
order, so a scatter clump can now cosmetically overwrite one tile of an
already-placed home-kit deposit, the same low-stakes trade already accepted
in the other direction (a bank deposit overwriting a scatter tile). Re-verified
on the original 3 failing seeds (now 0) and a fresh sample of 50 seeds each at
1/2/8 teams (0/50, 0/50, 1/50); the one remaining failure reproduces
identically - same stage, same error, same hash - on the pre-scatter binary
too, confirming it predates and is unrelated to any of today's work.

Also extended the fjord banks themselves from two fixed clumps per side into
a scattered strip: six more, lighter, best-effort clumps per side along the
same bank, mixing corn/wood/stone with a small jitter so the spacing doesn't
read as mechanical. Best-effort like the ambient scatter - nothing downstream
depends on any single one existing, so a spot that doesn't pan out is simply
skipped rather than failing generation. Walking a fjord's edge now reads as
following a shoreline with its own economy rather than passing exactly two
fixed deposits.

Verified: full scons client build and scons -C test suite (198 cppunit cases)
pass. MapGeneratorStudy succeeds across all 13 generators at 5 seeds each.
Rendered and inspected directly (MapGeneratorStudy's dump= option) to confirm
the coastal strip reads as intended alongside the interior scatter.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Make scatterResources actually prefer farmable ground, not just legal ground

Playing raised a real point: a lot of the resources scatterResources places
(Fjord, Lattice and Maze's shared ambient layer) sit far enough from water
that Map::growResources - which only regrows a wheat or wood tile near
water - will never bring them back once harvested. The candidate search
never looked at that: it drew a uniformly random legal tile and used it
outright, so a corn or wood clump could as easily land on a one-time find
as on real farmland.

First attempt required only Fertility::Field::at(x,y) > 0 (computed
ungated, since nothing has been harvested yet at this point in generation
to gate against). Measured before/after and it changed nothing: on these
water-heavy generators a single water tile up to 14 tiles away already
contributes a nonzero - if as little as 4 out of a 65536 maximum - weight
to nearly every legal tile, so ">0" was already true almost everywhere,
fertile-preferring or not.

Replaced the fixed bar with best-of-100: score every randomly drawn legal
candidate by fertility and keep the highest seen across the same 100-draw
budget, rather than stopping at the first past a threshold. A type that
doesn't regrow this way (stone, algae, fruit) scores every candidate 0 and
so keeps its original "first legal tile found" behaviour exactly.

Verified: full scons client build and scons -C test suite (198 cppunit
cases) pass. MapGeneratorStudy succeeds across all 13 generators (0
failures, 40 seeds each on Fjord/Lattice/Maze, 5 seeds each on the rest).
Measured against a meaningful bar (fertility > 8000, the same "genuinely
good farmland" reference StartQualityScale uses elsewhere) rather than the
uninformative ">0": the fraction of scattered corn/wood clearing it rose
from roughly 46% to 68% on Fjord, 40% to 88% on Lattice, and 52% to 96% on
Maze. Fjord's revision moves to 3 (already at 2 from the ambient-scatter
and coastal-strip work earlier in this branch); Lattice's and Maze's move
to 2, their first change since this branch introduced them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Paint ambient wheat/wood as a fertility-threshold band, not independent clumps

Playing surfaced two related complaints about scatterResources (Fjord,
Lattice, Maze's shared ambient resource layer): the result still read as
"clumpy" even after the earlier best-of-100 fertility fix, and a lot of
resources were landing far from water where Map::growResources can never
regrow them.

Root cause of the clumpiness: each clump is an independent random draw at
its own center, unlike generateHeightField (Swamp/River/Islands/Crater-
Lakes), which paints its resource bands as a histogram threshold over the
same Perlin noise field that carved the terrain, so resources follow the
same organic contours as the water and grass. scatterResources runs after
these generators' terrain is already fixed, so it can't share that field,
but it can still take the threshold approach.

First attempt thresholded an independent Perlin noise field. This did fix
the clumpiness, but measurement showed it quietly undid the fertility fix:
an unrelated noise field has no reason to prefer high-fertility ground, so
the fraction of scattered corn/wood clearing a meaningful fertility bar
fell back to roughly the pre-fix numbers on all three generators. Fixed by
thresholding directly on Fertility::Field for corn/wood instead: the field
is already smooth and highest near water, so painting its highest-fertility
share is simultaneously the coastal-band shape and a genuine preference for
farmable ground - one field, not two competing mechanisms. Stone and algae
keep the independent noise field, since they have no regrowth rule to
prefer. Re-measured against fertility > 8000: 76.5%/73.0% Fjord, 97.9%/98.4%
Lattice, 97.1%/98.4% Maze corn/wood - better than the prior best-of-100
numbers, not just recovered.

A single global threshold broke visibly on Lattice: its islets are separate
landmasses with their own fertility range, so a handful of tiles scoring
highest could absorb nearly the whole band, leaving most islets with none -
the same "resources aren't balanced" problem this scatter exists to fix, at
the scale of one islet instead of one player. computeLandComponents flood-
fills the map into connected non-water components once per call, and
scatterBand now runs its threshold independently within each component,
sized to that component's own share of the candidate pool. A single
connected map is one component covering everything (unchanged behaviour);
Lattice's islets each get their own proportional coastal fringe now.

Lattice and Maze also needed their guaranteed per-team wheat/wood placement
reordered ahead of the ambient scatter: the new band-shaped scatter can
solidly fill a team's small reserved area with a different resource type
before the guarantee gets a turn, failing generation outright.
scatterResources already skips any tile that already carries a resource, so
running the guarantee first and the ambient scatter after leaves the
reservation alone. (Fjord already worked this way from the earlier
swarm-footprint fix.)

Verified: 0 new failures across 60 seeds x {Lattice, Maze, Fjord}, the
extended {teams=2,4,8} x 50-seed sweep (the only two failures found,
Fjord seed 70/90 at teams=8, are confirmed pre-existing and unrelated by
direct comparison against the pristine pre-ambient-scatter commit), the
full 13-generator x 5-seed sanity sweep, and 198/198 cppunit tests.

Fjord's revision moves to 4, Lattice's and Maze's to 3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Split ambient farmland placement into where (fertility) vs which crop (noise)

Playing turned up a real bug in the fertility-band rewrite: corn and wood
were thresholded on the same Fertility::Field back to back (corn claims the
highest-fertility band, wood claims the next-highest of what's left), which
correctly picks where farmland goes but also, as a side effect, decides
which crop goes where using the same field - so corn and wood came out as
two concentric rings sorted by distance from water, with wood forming a
solid ring that visually hid wheat behind it, instead of sitting side by
side the way real farmland does.

Fertility should only ever answer "where can farmland go", never "which of
the two crops belongs here". Split into two independent steps in the new
scatterFarmland: fertility selects one shared candidate region for corn and
wood together (per land component, keeping the earlier per-islet fairness
fix), and a second, unrelated noise field splits that region into the two
crops - sorted by the noise field's own level and sliced into a corn share
and a wood share, so which crop lands where varies along the coast instead
of stacking into bands by distance from water.

Also widened the fertility-selected region to roughly 3x the requested tile
count (still per component), so it reaches into lower-but-still-farmable
ground instead of a razor-thin ring at only the very highest fertility
values, per a direct request for more resources at lower fertility.

Separately, widened Fjord's fjord-width control from 2-6 (default 3) to
2-10 (default 4). The wider default measurably increases single-seed
generation failures at the most crowded setting (teams=8: 8/100 vs. the
pre-existing 2/100 baseline, same "no swarm footprint fits" class as
before) but this isn't player-visible: both real entry points into
generation roll 5 candidate seeds and keep the best successful one rather
than committing to a single explicit seed, so the chance all 5 fail at once
is negligible. Settled on default 4 rather than 5 after measuring that 5
pushed the same failure rate to 24/100 - still low risk in practice via the
5-candidate roll, but 4 keeps a comfortable margin without giving up much
of the visual effect.

Verified: 0 new failures across 60 seeds x {Lattice, Maze, Fjord}, the
extended {teams=2,4,8} x 50-seed sweep (only the same two pre-existing
Fjord failures reproduced, confirmed unrelated in the prior commit), the
full 13-generator x 5-seed sanity sweep, and 198/198 cppunit tests.

Fjord's revision moves to 5, Lattice's and Maze's to 4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Fix wheat trapped behind a wood wall: finer split scale, per-component slack, reachability backstop

A screenshot from actual play caught what the aggregate fertility numbers
couldn't: a colony's wood had grown into one continuous, multi-tile-deep
wall along the entire visible coastline, with no corn in sight. This is a
real reachability bug, not a cosmetic one - resource tiles block ground
units, so the wheat behind that wall was genuinely unreachable.

Two compounding causes, both fixed:

1. The noise field that splits corn from wood reused the same smoothing
   factor (24) as the continent-shaping terrain noise, sized for whole-map
   features. At a player's zoomed-in scale, one crop's patch could easily
   span the entire visible screen - corn and wood alternated across the
   whole map, just not within reach of any one colony. Dropped to 6, sized
   for a working area instead of a continent.

2. The fertility-selected region's kWiden multiplier was checked against
   the map-wide candidate total, not the land component actually being
   painted, so a small Lattice islet's *entire* eligible pool could be
   absorbed by the widened region, leaving no slack to route around it.
   Confirmed by re-running the exact regression seeds against the prior
   commit: the same tiny islets had both wheat and wood reachable before
   this session's widening, and lost one or the other after it. Capped the
   widened region at 2/3 of each component's own pool, so every landmass
   keeps at least a third of its eligible ground unclaimed regardless of
   size.

As a second, independent backstop, Fjord and Lattice now call
guaranteeStartingResources after all resource placement - the same
reachability re-check RuggedArchipelago and ShatteredCoast already rely on
for this exact class of problem, clearing exactly the wall tiles
responsible for a cramped pocket before topping up whichever resource is
still out of range. Maze deliberately does not get this call: its maze
walls are themselves stone resource tiles by design, and the same
wall-clearing logic can't distinguish an intentional corridor wall from an
incidental one.

Verified with a new check beyond generation success: MapGeneratorStudy's
quality mode reports each colony's actual wheat/wood distance (-1 when
unreachable) via the same flood scoreStarts already computes. Across 60
seeds x {Lattice, Maze, Fjord} (720 colonies), unreachable cases dropped
from 7 to 3, and the 3 remaining are byte-for-byte identical to the
pre-session baseline on the same seeds (catchmentTiles=4 - a genuinely
tiny, isolated Maze room with no larger landmass behind a wall to reveal,
which guaranteeStartingResources correctly declines to force). Fjord
specifically, the generator in the screenshot, went from a visible wall to
0 unreachable colonies across the sweep.

Also re-verified the standard suite: 0 failures across the 60-seed x
3-generator sweep, the {teams=2,4,8} x 50-seed sweep (same class of
pre-existing fjord-width failures as before, none new), the 13-generator
sanity sweep, and 198/198 cppunit.

Fjord's revision moves to 6, Lattice's and Maze's to 5.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Give Fjord a central lake, richer core resources, and fix outlier islands

Three related requests plus one genuine bug found along the way.

A central lake: after the fjords are carved, a circle in shape-space
(lake-size percent of coreR, 0 disables it) becomes the map's shared
centerpiece, with a sandy no-man's-land ring around it (wider than
controlSand's own coastal fringe), several stone clumps and one clump of
every fruit type in the resource ring beyond the sand, and algae in the
lake itself. coreR widened from 0.19 to 0.23 of the continent radius to
make room for all of this without shortening the fjords.

Whether the fjords actually open into the lake is a real topology choice,
not a cosmetic one: doing so makes every peninsula water-isolated from its
neighbors (boats required to reach anyone), where stopping short behind a
solid land ring keeps today's mutual land connectivity. Asked directly
rather than guessed at - the answer was to keep both available as a
lake-connected control (default off), not pick one permanently. The global
cross-team connectivity check only runs when the land ring is expected to
exist; lake-connected maps are supposed to fail that specific check by
design, and each peninsula's own viability is still verified independently
further down regardless.

Separately, a real bug: outlier resource islands were going unplaced
almost every roll, at every map size - not just small ones, per a direct
report. The island annulus's inner bound summed every noise harmonic's
amplitude as if all four peaked at the same angle simultaneously, a safe
but very pessimistic bound the coastline never actually reaches, and that
overshoot doesn't shrink at larger map sizes since both terms scale
together. Sampling the coastline's true maximum over 360 angles helped but
wasn't enough on its own - the coastline's single worst direction can
legitimately approach half the map by itself on plenty of rolls. The real
fix: each island's center is already independently randomized, so it never
needed to be safe in the coastline's worst direction everywhere, only at
its own location - checking each candidate directly against the coastline
at its own position (the same per-point technique the open-sea algae
placement already uses) replaces one pessimistic global bound with an
exact local one. Verified by counting connected landmasses in dump=
renders: 10/10 seeds now place islands at 128, 256 and 512 map sizes,
versus roughly 1-in-5 before.

Verified: 0 failures across 60 seeds at default size, 30 at 256, 30 with
lake-connected=1, the {teams=2,4,8} x 50-seed sweep (teams=8 at 2/50,
within the already-accepted fjord-width margin), the 13-generator sanity
sweep, and 198/198 cppunit. The defaults test also caught two real
mistakes before they shipped: lake-size's default (45) wasn't a valid
step-10 grid point from minimum 0 (fixed by using step 5), and both new
control labels needed entries in data/texts.en.txt and data/texts.keys.txt
the same way every other control label already has one.

Fjord's revision moves to 7.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Widen resource-islands range, thicken core fruit, center lake algae, rebalance wheat:wood

Four follow-ups on the lake/island work.

resource-islands' range widened from 0-4 to 0-20 for players who want an
island-heavy map - the control itself was already meant to be that
flexible, but outlierCount was separately hard-capped at
std::min(4, ...), silently discarding anything the control asked for
beyond 4. Removed rather than raised, since the per-candidate placement
loop is already a safe best-effort search with no fixed ceiling of its
own.

The core's fruit went from one clump per type to three
(kFruitClumpsPerType), so the destination reads as a real grove of each
kind rather than a single token tree.

Separately, a real bug in the lake's algae: it was drawn from any water
tile in the whole lake, shore included, so a clump could anchor right at
the water's edge and read as stuck to one side rather than centered - the
same "random point instead of a deliberate one" issue as the earlier
per-component fertility work, just at lake scale. Fixed by always placing
one clump at the lake's exact geometric center (the shape transform's own
origin, provably inside the lake for any lake-size > 0) and drawing any
bonus clumps only from well inside the shoreline (lakeR - 4, not lakeR).
Verified directly with a temporary check, since the dump tool's tile
codes don't distinguish algae from plain water: the resource landed on
the computed center tile across every seed tested.

Last, direct feedback from playing: wood read as overrepresented against
wheat. Rebalanced every ambient/bonus layer to 2:1 corn:wood rather than
even, leaving every guaranteed placement (Fjord's starter kit and bank
guarantees, Lattice/Maze's guaranteed per-team clumps) at 1:1 - those
exist for reachability fairness, not feel, and depend on both resources
staying equally guaranteed. Fjord's ambient scatter moved from
corn=18/wood=18 to corn=24/wood=12 (same total density, reapportioned),
its bank-scatter roll distribution from even 3:3:2 to 4:2:2 (corn:wood:
stone of 8); Lattice's and Maze's wood control default halved from 50 to
25 against wheat's unchanged 50.

Verified the achieved ratio by counting resource tile codes across dump=
renders: Lattice and Maze land almost exactly at 2:1 (1.99:1, 1.97:1);
Fjord lands at 1.76:1, below 2:1 as expected since its untouched 1:1
guarantees dilute the ambient layer, still a clear shift from the prior
1:1 overall.

Verified: 0 failures across 60 seeds each for Fjord/Lattice/Maze, 30 seeds
at resource-islands=20, 20 at resource-islands=0, the {teams=2,4,8} x
50-seed sweep (teams=8 at 4/50, same already-accepted margin), the
13-generator sanity sweep, the defaults test, and 198/198 cppunit.

Fjord's revision moves to 9, Lattice's and Maze's to 6.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CkotX2daY8YTuFcRxFf5ba

* Fix fjords not actually reaching the lake in lake-connected mode

A screenshot showed the fjords visibly not connecting to the lake -
"almost connected but not quite," a thin sand gap sitting right where the
water should have merged.

First guess: the fjord's target tip radius (fjordInnerR) landed exactly
on the lake's boundary circle, so pushed it to lakeR * 0.4 (well inside
the lake), reasoning that would force real overlap. Built, verified by
flood-filling the water outward from the lake, and it changed nothing -
the connected-component size stayed ~35 tiles, the same as the lake
alone, never reaching the map edge.

The actual cause, found by reading Map::controlSand (MapTerrain.cpp): it
converts any water tile with a grass neighbor anywhere in its own 3x3
neighborhood to sand - not a coastal decoration, a channel eraser. The
fjord's tip tapers down to tipWidth = 0.65 (a ~1.3-tile diameter) as it
approaches its target radius; every tile in a channel that narrow borders
grass on both sides, so controlSand sands the entire tip shut regardless
of how deep its target radius reaches. Fine for disconnected mode, where
that's an intentional dead end against solid land - but it silently
closed the one connection lake-connected mode actually needs open. A
channel needs a surviving center row not touching grass on either side to
remain water at all, which takes a width (radius) of at least ~1.5.
Widened tipWidth to max(2.5, mouthWidth * 0.6) specifically in connected
mode, comfortably above that floor. The fjordInnerR change stays as a
small extra safety margin, though the width fix is what actually matters
- the lake is so small relative to the fjord's total length that moving
the target radius barely moves the width at the point the centerline
actually crosses the lake's boundary.

Verified with the same flood-fill check, now correctly: the lake's
connected-component water size jumps from ~35 tiles to ~11,000 and
reaches the map edge on every seed tested, confirming the outer sea and
the lake are now genuinely one connected body via every fjord.

Re-verified the standard suite: 0 failures across 60 seeds in
disconnected mode, 40 seeds at lake-connected=1, clean at teams 2/4 (0/3…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant